Skip to content

Handle unhandled Promise rejections in JavaScript#65

Open
acandoo wants to merge 2 commits intolpil:mainfrom
acandoo:handle-unhandled-promise-rejection
Open

Handle unhandled Promise rejections in JavaScript#65
acandoo wants to merge 2 commits intolpil:mainfrom
acandoo:handle-unhandled-promise-rejection

Conversation

@acandoo
Copy link

@acandoo acandoo commented Mar 14, 2026

Fixes #62

About

On all major server-side JavaScript runtimes, unhandled rejections of Promises by default lead to a fatal error and the program prematurely exiting. This can be demonstrated in pure Gleam:

//// test/wibble_test.gleam

import gleam/javascript/promise
import gleeunit

pub fn main() -> Nil {
  // All tests pass!
  gleeunit.main()
}

// This fails when run directly!
pub fn main_test() -> Nil {
  echo "testing"
  let promise1 =
    promise.new(fn(resolve) {
      echo "testing promise"
      panic
      resolve("Hello, World!")
    })

  Nil
}

However, attaching an event listener to unhandled rejections changes this behavior, and allows for detecting these errors. This PR handles these rejections and appends it as a failed test case from an unknown module and unknown function.

For testing, this PR also adds two dev dependencies envoy and shellout, as testing requires the test runner explicitly failing.

Caveats

This code assumes that ALL unhandled rejections within tests should be considered as failures. In some programs this may not be the case, as unhandled rejections don't ALWAYS lead to program exit if the program also subscribes to unhandled rejections through process.on. Some Gleam programs may want this behavior, but as it would lead to asserts and panics within unhandled rejections not being recognized. This code could be changed to either:

  • mark all unhandled rejections as failed tests (current behavior)
  • ignore unhandled rejections if they're already handled by the program
  • only mark Gleam-specific errors as test failures
  • some combination of the previous two

@acandoo acandoo changed the title Handle and test unhandled Promise rejections in JavaScript Handle unhandled Promise rejections in JavaScript Mar 14, 2026
@acandoo acandoo marked this pull request as draft March 14, 2026 15:32
@acandoo
Copy link
Author

acandoo commented Mar 14, 2026

oh gawd this is a rabbit hole...

so catching unhandled rejections through process.on effectively catches the promise, but doing it through globalThis.addEventListener or globalThis.onunhandledrejection just runs an event. you can "catch" the promise through either of the two and it will trigger the "rejectionhandled" event, but the process still exits with an error.

@acandoo
Copy link
Author

acandoo commented Mar 14, 2026

okay wait i think this makes life easier, i just have to check for nodejs-style catching of unhandled rejections, force push coming soon

@acandoo acandoo force-pushed the handle-unhandled-promise-rejection branch from 25cf70f to 3a0b018 Compare March 14, 2026 16:43
@acandoo
Copy link
Author

acandoo commented Mar 14, 2026

ok updated!

while i was at it i added logic so that process exit occurs at 'beforeExit', ie when the runtime has nothing else (like timers) keeping the program alive.

i also now made it so that all unhandled rejections are assumed to be failures, even if the program itself subscribes to unhandled rejections or handles it at a point where the program would normally crash without subscribing. i figured that logic wasn't worth keeping since realistically promise rejection should only happen when you WANT an exception to occur in the test. though i'll update the description because both behaviors to me make sense.

@acandoo acandoo marked this pull request as ready for review March 14, 2026 17:35
Copy link
Owner

@lpil lpil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Fantastic work! I've left some questions inline.

javascript_test_delayed_promise()
}
}
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this test, what is it doing? Why is it reading environment variables and spawning OS processes?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It respawns gleam test but using the JavaScript runtime and with an environment variable, as the test in question requires seeing if gleeunit exits with an error on an uncaught Promise rejection. Since there are two tests (immediate unhandled rejection, delayed unhandled rejection), it's spawned two times with the environment variable set for the two test cases to run. I was considering either this or making an OS temporary directory with a brand-new Gleam project, but figured this was easier to implement.

// The maximum amount of time running all tests is expected to take.
// This is used to set the timeout for the delayed promise test, to ensure
// it runs after all other tests have had a chance to execute.
const MAX_TEST_TIME = 4000;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is a bit confusing to me, it sounds like it is being used as the timeout for tests being run, but it seems to instead be used to reject one promise in the FFI below?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intention is for the Promise's rejection to outlast the regular time of the tests, as it's testing to make sure gleeunit doesn't prematurely exit before the Promise rejects.

export function delayed_promise_fail_test() {
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Promise panicked"));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says the promise panics, but there's no panic here.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, i'll update the message!

};

// If we're able to subscribe to beforeExit, then we can report unhandled rejections that
// occur after the tests are finished.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool! Which runtimes is this API not available for?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node, Deno, and Bun all cover it, so the edge case is if someone decides to run it on a Vercel Edge runtime or some really early version of Deno/Bun or a browser or something :p i can remove it if you want

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

panic does not trigger a failed test when within a JavaScript Promise

2 participants